在 Day 16 實裝主角與鍵盤移動後,在瀏覽器實機測試時暴露出四個嚴重的視覺與手感缺陷:
今天我們將深入 Three.js 著色器層面揭開 Sprite 無法水平縮放的真相,透過 UV 逆向採樣徹底解決轉向問題,重構四向移動狀態機與隨身火把動態換手機制,將角色身形放大至 3.8 倍霸氣比例,並導入時間獨立指數阻尼(Exponential Damping)打造緊湊平穩的追隨運鏡!
在常規的 3D 網格(THREE.Mesh)中,將 X 軸縮放設為負值(scale.x = -1)即可完成水平鏡像翻轉。然而,這套邏輯在 THREE.Sprite 上完全失效。
這並非 JavaScript 邏輯錯誤,而是 Three.js 內部 Sprite 頂點著色器機制所致。為了確保廣告看板(Billboard)面向攝影機時不變形,著色器在計算幾何尺寸時強制採用了歐幾里得向量長度函數:
在數學上,向量長度計算結果永遠為非負實數(絕對值)。因此,無論在 JavaScript 中將 playerSprite.scale.x 設為 -2.8 還是 -100,GPU 在頂點著色階段都會透過 length() 強制轉正為正值,導致角色永遠只能朝右跑。
幾何變換無法達成負縮放,必須改由紋理映射(UV Mapping)著手:
透過將貼圖寬度設為負值,GPU 片段著色器會由右向左逆向讀取像素,在完全不更動 Sprite 幾何矩陣的前提下達成無損水平鏡像翻轉。
橫向卷軸素材包通常只具備側面視角的走動幀。當玩家單純按住 W / S 在走廊移動或進行斜向輸入時,系統需要明確的方向意圖仲裁:
// 依據反饋進一步放大角色至 3.8,使其威武挺拔,顯著高於兩層石牆、與最高殘破主牆等高
//
const playerBaseScale = 3.8;
playerSprite.scale.set(playerBaseScale, playerBaseScale, 1);
// 建立主角群組並加入微光探險火把
const playerGroup = new THREE.Group();
playerGroup.add(playerSprite);
// :配合放大至 3.8 的角色身形,火把高度調升至 y=1.8 (胸口),左右手偏移距離擴大為 0.45,照明半徑調至 8.5
const playerLight = new THREE.PointLight('#ffb066', 2.3, 8.5, 1.2);
const torchOffsetX = 0.45; // 【DAY 17 新增】左右手持火把偏移量
playerLight.position.set(torchOffsetX, 1.8, 0.25);
playerGroup.add(playerLight);
// 初始化相機鎖定目標高度調高至新角色胸口高度 y=2.0 (原先 y=1.6)
cameraTarget.set(spawnPos.x, 2.0, spawnPos.z);
// 微調角色碰撞半徑為 0.34 (配合 3.8 巨大身形,且完美保留穿梭 1 格寬度窄門走廊的能力)
const playerCollisionRadius = 0.34;
將四向判定、UV 翻轉與動態換手整合至主角物理更新邏輯中:
// 記錄四向朝向狀態與面向左側旗標 (徹底解決 A 鍵向左走倒退滑步問題)
let currentDirection = 'right'; // 'left' | 'right' | 'up' | 'down'
let facingLeft = false;
function updatePlayer(dt) {
let moveX = 0;
let moveZ = 0;
if (keys.w) moveZ -= 1;
if (keys.s) moveZ += 1;
if (keys.a) moveX -= 1;
if (keys.d) moveX += 1;
const isMoving = (moveX !== 0 || moveZ !== 0);
if (isMoving) {
const len = Math.hypot(moveX, moveZ);
moveX /= len;
moveZ /= len;
// 1. 四向朝向狀態機判定 (依位移分量仲裁主導方向)
if (Math.abs(moveX) >= Math.abs(moveZ)) {
if (moveX < -0.01) {
currentDirection = 'left';
facingLeft = true;
} else if (moveX > 0.01) {
currentDirection = 'right';
facingLeft = false;
}
} else {
if (moveZ < -0.01) {
currentDirection = 'up';
} else if (moveZ > 0.01) {
currentDirection = 'down';
}
// 縱向移動帶有水平微偏時同步更新面向
if (moveX < -0.1) facingLeft = true;
if (moveX > 0.1) facingLeft = false;
}
// 2. 隨身火把動態換手機制 (面向左側換至左手 x=-0.45,面向右側換至右手 x=+0.45)
playerLight.position.x = facingLeft ? -torchOffsetX : torchOffsetX;
const dx = moveX * playerSpeed * dt;
const dz = moveZ * playerSpeed * dt;
const currX = playerGroup.position.x;
const currZ = playerGroup.position.z;
// 碰撞檢查與滑牆移動 (Wall Sliding)
if (!checkCollision(currX + dx, currZ + dz)) {
playerGroup.position.x += dx;
playerGroup.position.z += dz;
} else {
if (!checkCollision(currX + dx, currZ)) playerGroup.position.x += dx;
if (!checkCollision(currX, currZ + dz)) playerGroup.position.z += dz;
}
// 切換為跑步動作貼圖
if (currentAnim !== 'run') {
currentAnim = 'run';
currentFrame = 0;
animTimer = 0;
playerMat.map = runTex;
playerMat.needsUpdate = true;
}
} else {
// 待機時維持前次面向,火把同步維持正確手持側
playerLight.position.x = facingLeft ? -torchOffsetX : torchOffsetX;
if (currentAnim !== 'idle') {
currentAnim = 'idle';
currentFrame = 0;
animTimer = 0;
playerMat.map = idleTex;
playerMat.needsUpdate = true;
}
}
// 動畫幀輪播更新
const frameDuration = currentAnim === 'run' ? 0.09 : 0.16;
const totalFrames = currentAnim === 'run' ? RUN_COLS : IDLE_COLS;
animTimer += dt;
if (animTimer >= frameDuration) {
animTimer = 0;
currentFrame = (currentFrame + 1) % totalFrames;
}
// 透過 UV repeat.x 負值與 offset.x 偏移達成向左翻轉
if (playerMat.map) {
if (facingLeft) {
playerMat.map.repeat.x = -1 / totalFrames;
playerMat.map.offset.x = (currentFrame + 1) / totalFrames;
} else {
playerMat.map.repeat.x = 1 / totalFrames;
playerMat.map.offset.x = currentFrame / totalFrames;
}
}
// 維持精靈本體尺寸正向,縮放由 playerBaseScale 統一控制
playerSprite.scale.x = playerBaseScale;
}
先前跟隨運鏡存在兩項缺陷:

設定 $\text{camFollowSpeed} = 12.0$,相機每幀能以約 18% 的速度緊湊收斂目標差距,將滯後距離自 1.5 格大幅壓縮至 0.3 格以內,平穩鎖定角色的同時徹底消除擺盪眩暈感。
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.1);
const elapsedTime = clock.getElapsedTime();
// 壁燈火光呼吸搖曳
animatedLights.forEach(item => {
const flicker = Math.sin(elapsedTime * item.speed + item.phase) * 0.35 +
Math.cos(elapsedTime * item.speed * 2.1 + item.phase) * 0.2;
item.light.intensity = item.baseIntensity + flicker;
});
// 更新主角物理移動、狀態機與動畫
updatePlayer(dt);
// 相機動態追蹤主角 (修復鎖死問題 + 大幅減少晃動幅度)
const camFollowSpeed = 12.0; // 緊湊平穩跟隨速度 (大幅消除大幅晃動)
const camFactor = 1.0 - Math.exp(-camFollowSpeed * dt);
cameraTarget.x += (playerGroup.position.x - cameraTarget.x) * camFactor;
cameraTarget.z += (playerGroup.position.z - cameraTarget.z) * camFactor;
cameraTarget.y = 2.0; // 鎖定巨大主角胸口核心高度
camera.position.copy(cameraTarget).add(cameraOffset);
camera.lookAt(cameraTarget);
renderer.render(scene, camera);
}
【本日結語】
至此,Day 17 的所有技術盲區已全數解決。角色向左奔跑時透過 UV 逆向採樣精確轉身,手持火把在左右手之間切換自如,3.8 倍比例展現出與古堡高牆平分秋色的身形,相機以指數阻尼平穩跟隨,徹底告別了畫面晃動問題。

然而,眼前的世界雖然操控流暢,卻還缺少了那股讓玩家屏息的「電影級空氣感」——火把的火光缺乏溫暖朦朧的溢光漫射,遠處的殘垣也少了微縮模型特有的夢幻景深虛化。在接下來的 Day 18,我們將正式導入 Three.js 後處理管線(EffectComposer),實裝 Unreal Engine 級別的選擇性輝光(Unreal Bloom)、移軸微縮景深(Tilt-Shift Blur) 與 電影暗角(Vignette),為地宮披上一層如夢似幻的朦朧光暈,將畫面質感直接推升至商業大作水準!